iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Build on Google AI

LOCAL:30 天打造 LINE × Google AI 地方服務 Agent系列 第 9 篇

Day 9|按兩次送出,會不會多一筆?受控建單與冪等

  • 分享至 

  • xImage
  •  

在 LOCAL 裡問完花壇場次的集合地點,你確認好詢問內容、按了送出,想想又按一次:「剛剛到底有沒有收到?」今天讓 Gemini 透過 Google ADK(Agent Development Kit,串接模型與工具的開發框架)建立一張有編號的服務請求,再送同一件事仍取回原單。兩次送出,資料庫為什麼只留一筆?

Day 9 connects a confirmed local-service question to a stored handoff request. Gemini calls an ADK function tool; application code verifies the server-side confirmation and writes to SQLite. We compare a first submission, an identical retry, and a conflicting reuse of the same key. The lesson is how confirmation, database constraints, and tool receipts work together.

先看本次結果:送出兩次,資料庫留下幾筆?

LOCAL Day 9 建單與重送結果
圖 1:離線核心實驗的靜態結果報告。先比較兩次送出的單號,再看資料庫筆數;本次確認由測試程式代送。

本次操作 工具判定 請求單號 資料筆數
第一次送出 request_created req-20260922-7292728324c6bb72 1
同鍵同內容再送一次 already_created req-20260922-7292728324c6bb72 1
同鍵改成另一個問題 idempotency_conflict — 1
另一份尚未確認的草稿 unconfirmed_operation — 1

先只看前兩列:第二次送出沒有拿到新單號,資料庫也沒有變成兩筆。這種「同一個操作重送多次,仍指向同一筆建立結果」的特性,就是本篇要處理的冪等(Idempotency)。

上表是離線核心實驗。對照組是一張只做 INSERT 的簡化 SQLite 表,每收到一次相同詢問就新增一列;同樣送兩次後留下 2 筆。受控版本則以同一組送出鍵重送,最終留下 1 筆;資料列狀態為 pending_human_review,也就是本機標記為「待真人處理」,但本篇尚未通知或交給真人。SQLite 是把資料庫保存在檔案裡的引擎,本次實際寫入就在這個本機檔案中。

本機離線回歸共 51 項通過:確認/建單核心 38 項、報告與紀錄 6 項、ADK 介接 7 項。另一份 ADK 替身實驗核對工具參數、回傳與資料庫結果。

同一流程交給 Gemini(真實 API)

模型 思考等級 模型請求 工具執行 資料筆數
gemini-3.8-flash LOW(低) 4 2 1

第一次送出(3.314 秒):

已成功為您建立人工服務請求!

  • 請求編號:req-20260923-a85c810bbe51c99d
  • 狀態:等待真人受理中

後續請靜候專人為您處理與回覆。

同一組參數重送(3.981 秒):

核對結果如下:

此筆請求先前已經建立過,本次沒有新增第二筆。

  • 原請求編號:req-20260923-a85c810bbe51c99d
  • 目前狀態:等待真人受理中

用量依本次回傳:輸入 4904、輸出 372、思考未回傳、總計 5276 token(模型計算文字用量的單位)。

我的判讀:

離線核心實驗先回答後端問題:刻意省略查重的簡化對照連續寫入兩次,受控版本則在同一送出鍵重送後只保留一筆。這組 2 對 1 的結果屬於 OFFLINE_CORE,對照組的 2 筆另以同一 run 的 naive.sqlite3 資料列為準。

live(真實 API)Gemini 是另一個 run。這一次兩回合裡,模型都實際呼叫 create_handoff_request,送出的 idempotency_key、confirmation_id、request_text 與 event_id 都和測試入口提供值一致;第一次工具回 request_created,第二次回 already_created,兩次的 request_id 都是 req-20260923-a85c810bbe51c99d,而 live SQLite 最終只有一列。這證明的是本次兩回合的工具呼叫與回條鏈,不代表模型在其他輸入下都會有相同行為。

第二回合回覆有正確說明「沒有新增第二筆」,也保留原單號與 pending_human_review 的狀態。第一回合最後一句「後續請靜候專人為您處理與回覆」則比本篇實作多了一層未來承諾:目前只有 local_sqlite_only,尚未通知或交給真人。這也是為什麼本篇把「建立請求」與「真人已受理」分開。

一、那句「好」,今天終於接到收件盒

Day 8 已把「誰確認過哪份內容」記下來。今天沿用花壇場次的教學快照與測試服務窗口,把「請問集合地點在哪裡?」收成一張有編號的請求。

這像是把寫好的便條放進收件盒,再拿到一張回條。有了編號,使用者可以核對送出的結果,服務窗口也有固定的紀錄可接手。

執行時直接載入前篇的 ConfirmationStore,使用同一套核心建立本次確認。確認由測試程式明確代送,實際新增的是本機 SQLite 裡的服務請求。 Day 8 的歷史收據留作證據,當次有效的確認則在這次行程裡重新建立。

已確認的詢問 → Gemini 提出建單 → 程式核對 → 資料庫保存 → 取得請求編號。

Day 2 的同鍵重試原則,就在這裡接回 Agent。search_local_events 先幫忙找活動,今天加入的 create_handoff_request 再把需要協助的問題留下來,成為 Day 1 三項工具中的第二項。

二、先跑一次,看見「兩筆」和「一筆」的差別

從已下載的 Repo 根目錄執行:

python3 examples/day09/demo.py

這個示範只用 Python 內建功能。它會印出新產生的 REPORT.html 路徑;用瀏覽器打開,先比較前兩列的單號,再看最右邊的筆數。

同一份報告也有一個刻意設計的對照:每次收到詢問就直接新增,完全省略查重。對照組與受控版本各用自己的 SQLite 檔案;同樣送兩次,差別會直接留在資料庫裡。[3]

REPORT.html 是實際結果的靜態報告,重做實驗請再次執行指令。每一輪另開資料夾,舊結果照樣留著。這次比的不是誰的畫面比較漂亮,而是同一件事究竟收了幾次。

三、確認碼、送出鍵、請求編號,各管一件事

如果每收到一個請求就發新單號,程式確實很勤勞,但服務窗口會多一份工作。要認出「又送了一次」,需要比單號更早出現的識別。

名稱 要回答的問題
confirmation_id(確認識別碼) 使用者看過並確認的是哪份內容?
idempotency_key(冪等鍵) 這是不是同一次送出的重試?
request_id(請求編號) 後端最後收成哪一張單?

冪等鍵由應用端替這次送出配置,重送沿用原鍵。它像取件憑單上的號碼:再拿來一次,就去找原本那件;真的要辦另一件事,才另開新的確認與送出鍵。本例單號中的日期以 UTC 產生,所以可能和臺灣當地日期差一天。

本篇把鍵的範圍限定在服務單位+使用者。tenant_id 在這裡只是服務單位識別;不同窗口或不同使用者碰巧使用相同鍵,各自仍能處理自己的詢問。回條還會核對本次對話,避免拿錯人的內容。

四、Gemini 提出工具呼叫,程式把這張單收好

本篇用 Google ADK 把 Gemini、Python 工具與對話流程接起來;ADK 會依函式簽名、型別與說明,建立模型可使用的工具介面。[1]

以下是 adk_bridge.py 的真實工具簽名節錄,主體省略;完整檔案可以直接對照:

    def create_handoff_request(
        idempotency_key: str,
        confirmation_id: str,
        request_text: str,
        event_id: str,
        tool_context: ToolContext | None = None,
    ) -> dict[str, Any]:
        ...

這四個業務參數分別是送出鍵、確認碼、詢問文字與活動。ToolContext 則是 ADK 執行工具時自動提供的環境物件;本篇用它核對 Session(這一段對話的工作階段)。[2] 服務單位、使用者與權限由可信應用端提供,工具收到四個參數後仍要查原紀錄。[1]

給初學者:為什麼指令開頭是 .venv?

Python 預設會把套件安裝到整台電腦共用的全域目錄,當不同專案需要的套件版本衝突時,就容易互相干擾。.venv(Virtual Environment,虛擬環境) 本質上只是一個輕量的專屬資料夾,讓每個專案都有自己的獨立套件箱,不需要時直接刪除資料夾即可。

一般教學常使用 source .venv/bin/activate(Windows 為 activate.bat)啟動環境,但在多個終端機視窗切換時容易切錯。本系列採用直接指定直譯器路徑的寫法,可以降低在多個終端機之間切錯環境的機會:

路線一:沿用 Day 5 既有環境(若已跟著做到 Day 5)

PY=examples/day05/.venv/bin/python
$PY examples/day09/verify.py --sdk
$PY examples/day09/run.py

路線二:建立全新 Day 9 獨立環境(從零開始或獨立測試的新讀者)
從 Repo 根目錄執行:

python3 -m venv examples/day09/.venv
examples/day09/.venv/bin/python -m pip install -r examples/day09/requirements.txt

PY=examples/day09/.venv/bin/python
$PY examples/day09/verify.py --sdk
$PY examples/day09/run.py

Day 9 直接沿用 Day 5 已裝好 ADK 的環境,省去重複下載的等待;若你是第一次跟著操作的新讀者,照路線二建立即可。

verify.py --sdk 執行完整離線測試;run.py 則讓真正的 ADK **Runner(推進模型與工具往返的執行器)**搭配固定腳本的模型替身,跑第一次建單與再次送出。替身的回答是預先安排的,適合檢查接線;本次 Gemini 回覆的語意表現另外看真實 API 原文。

準備好 GEMINI_API_KEY,確認本次呼叫範圍後,才執行真實模型:

$PY examples/day09/run.py --live --approve-live

這個入口沿用前篇的模型與 LOW 設定,兩回合總計最多六次模型請求、兩次工具執行;私人設定檔的指定方式見 README。測試入口提供已確認的參數,Gemini 負責提出工具呼叫並依回條回答。

看結果時,順著模型提出的參數 → 工具回條 → 資料庫那一列 → 最後回覆對一次,就知道編號從哪裡來。

踩坑筆記:我這次遇到的 403

我第一次在 Antigravity 的終端機跑 live 實驗時,遇到 403 ... not allowed by policy。在我這次環境裡,問題不是建單程式本身,而是執行環境尚未允許對外連線;我用 curl -I https://www.google.com 交叉檢查時也無法連出,調整該環境的網路權限後才恢復。

這只是本次開發環境的排查紀錄,不代表所有 403 都有相同原因。若一般終端機也失敗,再分別檢查 API、金鑰與帳戶設定;產品名稱與主控台選項可能變動,以當下官方介面與文件為準。

五、光是「先查再寫」,為什麼還不夠?

想像兩個送出請求同時進來,各自查到「還沒有這張單」,接著都新增。單獨測兩次沒事,同時送來卻變成兩張;問題在於兩步中間留了空隙。

本篇把查重與新增放進同一筆 SQLite 交易(Transaction):把一組資料庫操作作為一個整體處理,成功才提交;失敗則還原未提交的修改。以下節錄 handoff.py:

                conn.execute('BEGIN IMMEDIATE')

BEGIN IMMEDIATE 先取得寫入交易,再查詢、核對與新增;另一個寫入者會等待,或得到資料庫忙碌的結果。本例將忙碌回傳為 storage_busy,與建單成功分開;本篇沒有另外製造 SQLITE_BUSY 來驗證這條錯誤分支。[3]

資料表另有兩條唯一性限制(UNIQUE),要求指定欄位組合在表內保持唯一:

 UNIQUE(tenant_id, user_id, idempotency_key),
 UNIQUE(tenant_id, user_id, confirmation_id)

第一條保護同一使用者的同一次送出;第二條讓同一份確認只對應一張請求。程式檢查加上資料庫限制,兩層一起守住這個結果。[4]

查到原鍵後,還會比對內容。以下同樣節錄自 handoff.py:

                    if row['payload_hash'] != digest:
                        conn.rollback()
                        return self._error('idempotency_conflict')

payload_hash 是把確認碼、詢問文字、活動、操作類型與目的地固定編碼後,計算出的 SHA-256 內容指紋,用來比對這次送來的參數。[5] Day 8 的 operation_fingerprint 也保存在資料庫,追溯當時確認的完整操作;兩個值分別處理「重送參數是否相同」與「原本看過什麼」。

六、再按一次,和換一個問題,是兩回事

今天的反例很生活化:沿用原鍵,把「集合地點在哪裡?」換成「附近哪裡可以停車?」。

這次應回 idempotency_conflict,原單編號與內容保持原樣。要送新問題,回到內容確認取得新鍵就好。這裡的衝突由測試程式刻意送入,用來解釋規則,沒有把它寫成模型真的犯過的錯。

還有一個值得區分的情況:單子已建立,使用者晚一點重送,這時確認期限過了。

我的選擇是:目前仍有權限、本人與對話相符、同鍵同內容,就取回原回條;第一次建單才要求確認仍然有效。 前者讀取已發生的結果,後者才會新增資料。這個區別,也替 Day 10 的逾時查回留下接點。

尚未確認的另一份草稿會得到 unconfirmed_operation。服務端先檢查原紀錄確實已確認,再交回 Day 8 核心重核內容、版本與期限;Day 8 的 execution_allowed: false 仍保持原義,執行授權另外由今天的建單服務決定。

七、取捨:先把同一張單收好,再談雲端

本篇的 CONTRACT.md 對照主要行為與測試。這組固定輸入、預期結果和驗證方法,就是今天的 Harness(驗收框架);它讓我們每次改程式,都能重看同一套問題。

核心回歸包含六個請求同時送出;ADK 另核對工具參數、回傳與真實資料列。測試回答「單號和筆數對不對」,我再閱讀模型原文,判斷回覆是否自然、是否把等待受理講成已有人處理。

我先用本機 SQLite,因為打開檔案就能檢查,離線也能帶讀者做完。請求已經保存,確認狀態仍在記憶體。 重開資料庫可讀到原單;還沒執行的確認則需重新建立,整段對話的重啟接續留給後篇。

Day 9 的服務端身分範圍與 SQLite 交易是在這個小型教學應用中使用;後續接 LINE 與 Firestore 時,登入、儲存交易與通知還有各自的接點。今天先把「請求已建立」和「等待真人受理」說準。

八、今天有單號,下一篇處理「沒收到回覆」

Day 1 的 handoff-timeout-001 要求用原鍵核對,Day 2 示範過資料已寫入但回覆遺失。今天把確認、工具與後端接成一條線;下一篇再把故障注入 Agent 流程,並加上 GitHub Actions 的離線 CI(持續整合檢查)。[6]

重送時取回同一張回條,使用者少一個疑問,服務窗口也少一張重複單。 你最想先把這個方法用在活動詢問、客服留言,還是哪一種常被連按兩次的服務?

程式與參考資料

前篇:Day 8|一句「好」不夠:確認綁定具體操作。

本篇程式:examples/day09;操作:README;規格:CONTRACT.md。


上一篇
Day 8|一句「好」不夠:確認綁定具體操作
下一篇
Day 10|逾時後到底有沒有送出?
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言